
using System;
using System.Collections.Generic;
using System.Text;
using ScrewTurn.Wiki.PluginFramework;
using System.IO;
using System.Web;
using System.Text.RegularExpressions;
using System.Globalization;

namespace ScrewTurn.Wiki.PluginPack {

	/// <summary>
	/// Implements a Basic Statistics Plugin.
	/// </summary>
	public class BasicStatsPlugin : IFormatterProvider {

		private const string HitsFile = "Hits.cs";

		private IHost host;
		private string config;
		private ComponentInformation info = new ComponentInformation("Basic Statistics Plugin 1.3.3", "ScrewTurn Software", "http://www.screwturn.eu");

		private int cacheSize = 50;
		private int maxSessions = 50;
		private bool publicAccess = false;
		private bool nobots = true;
		private List<string> excludedUsers;
		private List<string> botSignatures;
		private List<string> excludedPages;
		private string magicWord = null;
		private int maxLanguages = 5;
		private int sessionsDivHeight = 300;

		private List<ExtendedPageHit> hits;
		private Regex statsRegex = new Regex("\\{stats\\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
		private object fileLock = new object();

		public void Init(IHost host, string config) {
			this.host = host;
			this.config = config;

			excludedUsers = new List<string>(10);
			botSignatures = new List<string>(30);
			PopulateExcludedUserAgents(botSignatures);
			excludedPages = new List<string>(10);

			DecodeConfig(this.config);

			if(!File.Exists(host.GetSettingValue(SettingName.PublicDirectory) + HitsFile)) {
				File.Create(host.GetSettingValue(SettingName.PublicDirectory) + HitsFile);
			}

			hits = new List<ExtendedPageHit>(cacheSize);

			host.AddToolbarItem(ToolbarItem.SpecialTag, "{Stats}", "{STATS}");
		}

		/// <summary>
		/// Validates and decodes the configuration.
		/// </summary>
		/// <param name="config">The configuration.</param>
		private void DecodeConfig(string config) {
			if(string.IsNullOrEmpty(config)) return;

			string[] lines = config.Replace("\r", "").Split(new char[] { '\n' }, StringSplitOptions.RemoveEmptyEntries);

			string[] fields;
			foreach(string line in lines) {
				if(!line.Contains("=")) continue;

				fields = line.Split('=');

				try {
					switch(fields[0].ToLowerInvariant().Trim()) {
						case "cachesize":
							cacheSize = int.Parse(fields[1].Trim());
							break;
						case "publicaccess":
							publicAccess = bool.Parse(fields[1].Trim());
							break;
						case "maxsessions":
							maxSessions = int.Parse(fields[1].Trim());
							break;
						case "nobots":
							nobots = bool.Parse(fields[1].Trim());
							break;
						case "exclude":
							excludedUsers.Add(fields[1].Trim());
							break;
						case "magicword":
							magicWord = fields[1].Trim();
							break;
						case "maxlanguages":
							maxLanguages = int.Parse(fields[1].Trim());
							break;
						case "botsignature":
							botSignatures.Add(fields[1].Trim().ToLowerInvariant());
							break;
						case "dontshowpage":
							excludedPages.Add(fields[1].Trim().ToLower(CultureInfo.CurrentCulture));
							break;
						case "dontshowmainpage":
							if(bool.Parse(fields[1].Trim())) excludedPages.Add(host.GetSettingValue(SettingName.DefaultPage).ToLower(CultureInfo.CurrentCulture));
							break;
						case "sessionsdivheight":
							sessionsDivHeight = int.Parse(fields[1].Trim());
							break;
					}
				}
				catch {
					// Any exception is thrown by a parse failure
					throw new InvalidOperationException();
				}
			}
		}

		private void PopulateExcludedUserAgents(List<string> ua) {
			ua.AddRange(new string[] { "googlebot", "msnbot", "slurp", "ia_archiver", "tailrank", "searchme" });
			ua.AddRange(new string[] { "msrbot", "ask jeeves", "techrigybot", "snapbot", "daum rss robot" });
			ua.AddRange(new string[] { "suchen.de", "vadixbot", "pagebull", "missigua locator", "sygolbot" });
			ua.AddRange(new string[] { "mj12bot", "wells search ii", "igdespyder", "yahooseeker" });
			ua.AddRange(new string[] { "kalooga.com", "powerset.com" });
			ua.AddRange(new string[] { "spider", "crawler", "bot" });
		}

		public void Shutdown() {
			// Dump remaining hits on disk
			DumpHits();
		}

		public ComponentInformation Information {
			get { return info; }
		}

		public bool PerformPhase1 {
			get { return false; }
		}

		public bool PerformPhase2 {
			get { return false; }
		}

		public bool PerformPhase3 {
			get { return true; }
		}

		public string Format(string raw, ContextInformation context, FormattingPhase phase) {
			if(phase == FormattingPhase.Phase3 && context.Page != null) {
				HttpRequest req = HttpContext.Current.Request;
				if(nobots && !IsBot() && !excludedUsers.Contains(context.Username) && !IsMagicWordEngaged(req)) {
					string referer = "";
					try {
						// UrlReferrer sometimes throws an exception with no apparent reason
						referer = req.UrlReferrer != null ? req.UrlReferrer.ToString() : "";
					}
					catch { }
					// Store page hit
					ExtendedPageHit h = new ExtendedPageHit(HttpContext.Current.Session.SessionID,
						req.UserHostAddress, context.Page.Name, referer,
						req.UserAgent != null ? req.UserAgent : "",
						PrepareUserLanguage(req.UserLanguages),
						context.Username != null ? context.Username : "", DateTime.Now);

					lock(hits) {
						hits.Add(h);
						if(hits.Count >= cacheSize) DumpHits();
					}
				}
			}

			// Render the stats display, if needed and allowed
			Match m = statsRegex.Match(raw);
			if(m.Success) {
				StringBuilder sb = new StringBuilder(raw);
				sb.Remove(m.Index, m.Length);
				if(context.UserIsAdmin || publicAccess) {
					sb.Insert(m.Index, RenderStats(context.UserIsAdmin));
				}
				return sb.ToString();
			}
			else return raw;
		}

		private string PrepareUserLanguage(string[] langs) {
			if(langs == null || langs.Length == 0) return "";
			string ul = langs[0];
			if(ul.Contains(";")) {
				return ul.Substring(0, ul.IndexOf(";"));
			}
			else return ul;
		}

		private bool IsMagicWordEngaged(HttpRequest req) {
			if(string.IsNullOrEmpty(magicWord)) return false;
			else return !string.IsNullOrEmpty(req[magicWord]);
		}

		private bool IsBot() {
			string ua = HttpContext.Current.Request.UserAgent;
			if(string.IsNullOrEmpty(ua)) return false;

			ua = ua.ToLowerInvariant();
			return botSignatures.Exists(delegate(string s) { return ua.Contains(s); });
		}

		/// <summary>
		/// Dumps all the Page Hits on disk.
		/// </summary>
		private void DumpHits() {
			lock(hits) {
				StringBuilder sb = new StringBuilder(hits.Count * 50);
				foreach(ExtendedPageHit hit in hits) {
					sb.Append(hit.Serialize());
					sb.Append("\r\n");
				}
				lock(fileLock) {
					File.AppendAllText(host.GetSettingValue(SettingName.PublicDirectory) + HitsFile, sb.ToString());
				}
				hits.Clear();
			}
		}

		/// <summary>
		/// Renders the statistics to XHTML.
		/// </summary>
		/// <returns>The XHTML.</returns>
		private string RenderStats(bool admin) {
			// 1. Load all hits, memory then file
			// 2. Identify sessions
			// 3. Sort in-session hits
			// 4. Render

			// Dictionary (hashtable) to increase performance
			Dictionary<string, SessionInformation> tempSessions = new Dictionary<string, SessionInformation>(1000);

			lock(hits) {
				foreach(ExtendedPageHit hit in hits) {
					if(!tempSessions.ContainsKey(hit.SessionID)) {
						tempSessions.Add(hit.SessionID, new SessionInformation(hit.SessionID, hit.IP, hit.UserAgent, hit.Language, hit.Username));
					}
					tempSessions[hit.SessionID].Hits.Add(new PageHit(hit.Page, hit.Referer, hit.DateTime));
				}
			}

			lock(fileLock) {
				// Read the file line-by-line to reduce memory usage

				FileStream fs = new FileStream(host.GetSettingValue(SettingName.PublicDirectory) + HitsFile, FileMode.Open, FileAccess.Read, FileShare.Read);
				StreamReader reader = new StreamReader(fs);

				while(!reader.EndOfStream) {
					try {
						ExtendedPageHit h = ExtendedPageHit.Deserialize(reader.ReadLine().Trim('\r', '\n'));
						if(!tempSessions.ContainsKey(h.SessionID)) {
							tempSessions.Add(h.SessionID, new SessionInformation(h.SessionID, h.IP, h.UserAgent, h.Language, h.Username));
						}
						tempSessions[h.SessionID].Hits.Add(new PageHit(h.Page, h.Referer, h.DateTime));
					}
					catch {
						break;
					}
				}

				reader.Close();
			}

			if(tempSessions.Count == 0) return "";

			// Build a list out of the dictionary, and sort hits and sessions
			List<SessionInformation> sessions = new List<SessionInformation>(tempSessions.Count);
			foreach(string key in tempSessions.Keys) {
				sessions.Add(tempSessions[key]);
				sessions[sessions.Count - 1].Hits.Sort();
			}
			sessions.Sort();

			TimeSpan life = sessions[sessions.Count - 1].DateTime - sessions[0].DateTime + sessions[sessions.Count - 1].Duration; // Sessions are sorted asc

			Dictionary<string, int> allHits = new Dictionary<string, int>(sessions.Count * 5);
			Dictionary<string, int> tempLanguages = new Dictionary<string, int>(10);
			double totalDuration = 0;

			int[] sessionsByHour = new int[6];
			int[] sessionsByDayOfWeek = new int[7];
			int m = 0;
			int y = DateTime.Now.Year - sessions[0].DateTime.Year;
			m = DateTime.Now.Month + y * 12 - sessions[0].DateTime.Month + 1;
			if(m > 12) m = 12;
			int[] sessionsByMonth = new int[m];
			int windows = 0, linux = 0, mac = 0;
			int firefox = 0, ie = 0, safari = 0;

			// Render the results

			StringBuilder sb = new StringBuilder(5000);
			sb.Append("<div id=\"StatsDiv\">%%%SSS%%%<br /><br /><b>Details for the last ");
			sb.Append(maxSessions.ToString());
			sb.AppendFormat(" Sessions</b><div id=\"SessionsDiv\" style=\"height: {0}px; overflow: auto; border: solid 1px #CCCCCC;\">", sessionsDivHeight);
			sb.Append("<table cellspacing=\"0\" cellpadding=\"2\">");
			sb.Append("<tr style=\"background-color: #DDDDDD;\"><td><b>IP</b></td><td><b>User Agent</b></td><td><b>Language</b></td><td><b>Username</b></td><td><b>Date/Time</b></td><td><b>Duration</b></td><td><b>Hits</b></td></tr>");
			int cnt = 0;
			for(int i = sessions.Count - 1; i >= 0; i--) {
				if(cnt < maxSessions) { // Limit # of displayed sessions
					if(i % 2 == 0) sb.Append("<tr><td>");
					else sb.Append("<tr style=\"background-color: #F4F4F4;\"><td>");
					if(admin) sb.Append(sessions[i].IP);
					else sb.Append("***.***.***.***");
					sb.Append("</td><td style=\"width: 400px; font-size: 9px;\">");
					sb.Append(sessions[i].UserAgent);
					sb.Append("</td><td>");
					sb.Append(sessions[i].Language);
					sb.Append("</td><td>");
					if(!string.IsNullOrEmpty(sessions[i].Username)) {
						if(admin) sb.Append(sessions[i].Username);
						else sb.Append("****");
					}
					sb.Append("</td><td>");
					sb.Append(host.AlignDateTimeWithPreferences(sessions[i].DateTime).ToString(host.GetSettingValue(SettingName.DateTimeFormat)));
					sb.Append("</td><td>");
					sb.Append(PrintTimeSpan(sessions[i].Duration, false, true));
					sb.Append("</td><td>");
					sb.Append(sessions[i].Hits.Count.ToString());
					sb.Append("</td>");
					sb.Append("</tr>");
				}

				sessionsByHour[sessions[i].DateTime.Hour / 4]++;
				sessionsByDayOfWeek[DayOfWeekNumber(sessions[i].DateTime.DayOfWeek)]++;
				m = GetMonth(sessions[i].DateTime, sessionsByMonth.Length);
				if(m != -1) sessionsByMonth[m]++;

				if(!string.IsNullOrEmpty(sessions[i].Language)) {
					string lang = sessions[i].Language.ToLowerInvariant();
					if(!tempLanguages.ContainsKey(lang)) {
						tempLanguages.Add(lang, 0);
					}
					tempLanguages[lang]++;
				}

				if(!string.IsNullOrEmpty(sessions[i].UserAgent)) {
					string ua = sessions[i].UserAgent.ToLowerInvariant();
					if(ua.Contains("windows")) windows++;
					else if(ua.Contains("linux") || ua.Contains("unix")) linux++;
					else if(ua.Contains("macintosh")) mac++;
					if(ua.Contains("firefox")) firefox++;
					else if(ua.Contains("ie")) ie++;
					else if(ua.Contains("safari")) safari++;
				}

				// Store all hits
				foreach(PageHit hit in sessions[i].Hits) {
					if(!allHits.ContainsKey(hit.Page)) allHits.Add(hit.Page, 0);
					allHits[hit.Page]++;
				}

				cnt++;

				totalDuration += sessions[i].Duration.TotalSeconds;
			}
			sb.Append("</table>");
			sb.Append("</div>"); // SessionsDiv
			sb.Append("</div>"); // StatsDiv
			sb.Append("<small><br />Generated with <a href=\"http://www.screwturn.eu/Plugins.ashx#BSP\" title=\"ScrewTurn Wiki Basic Statistics Plugin\">Basic Statistics Plugin</a>.</small>");

			// Sort pages by hit count
			List<PageHitCount> pages = new List<PageHitCount>(allHits.Count);
			int totalHits = 0;
			foreach(string page in allHits.Keys) {
				pages.Add(new PageHitCount(page, allHits[page]));
				totalHits += allHits[page];
			}
			allHits = null; // Cleanup memory
			// Sort in desc order
			pages.Sort(delegate(PageHitCount p1, PageHitCount p2) {
				return p2.HitCount.CompareTo(p1.HitCount);
			});

			// Generate languages sorted lists
			List<KeyValuePair<string, int>> languages = new List<KeyValuePair<string,int>>(tempLanguages.Count);
			foreach(string key in tempLanguages.Keys) {
				languages.Add(new KeyValuePair<string,int>(key, tempLanguages[key]));
			}
			tempLanguages = null; // Cleanup memory
			// Sort in desc order
			languages.Sort(delegate(KeyValuePair<string, int> kvp1, KeyValuePair<string, int> kvp2) {
				return kvp2.Value.CompareTo(kvp1.Value);
			});

			int sessionsDay = (int)Math.Ceiling(sessions.Count / life.TotalDays);
			int sessionsMonth = sessionsDay * 30;
			int hitsDay = (int)Math.Ceiling(totalHits / life.TotalDays);
			int hitsMonth = hitsDay * 30;

			StringBuilder sb2 = new StringBuilder(500);

			sb2.Append("<table><tr><td style=\"vertical-align: top;\">");

			// Sessions by week chart
			int max = 0;
			for(int i = 0; i < sessionsByDayOfWeek.Length; i++) {
				if(sessionsByDayOfWeek[i] > max) max = sessionsByDayOfWeek[i];
			}
			sb2.AppendFormat("<table style=\"border: solid 1px #CCCCCC;\" cellspacing=\"2\"><tr><td colspan=\"{0}\" style=\"font-size: 10px; text-align: center; border-bottom: solid 1px #CCCCCC;\">Sessions/Weekday [%]</td></tr><tr>", sessionsByDayOfWeek.Length);
			for(int i = 0; i < sessionsByDayOfWeek.Length; i++) {
				sb2.AppendFormat("<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
					(sessionsByDayOfWeek[i] > 0 ? ((double)((double)sessionsByDayOfWeek[i] / (double)sessions.Count * 100D)).ToString("N1") : ""),
					(int)((double)sessionsByDayOfWeek[i] * 100D / (double)max));
			}
			sb2.Append("</tr><tr>");
			for(int i = 0; i < sessionsByDayOfWeek.Length; i++) {
				sb2.AppendFormat("<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>", DayOfWeekString(i));
			}
			sb2.Append("</tr></table><br />");

			// Sessions by hour chart
			max = 0;
			for(int i = 0; i < sessionsByHour.Length; i++) {
				if(sessionsByHour[i] > max) max = sessionsByHour[i];
			}
			sb2.AppendFormat("<table style=\"border: solid 1px #CCCCCC;\" cellspacing=\"2\"><tr><td colspan=\"{0}\" style=\"font-size: 10px; text-align: center; border-bottom: solid 1px #CCCCCC;\">Sessions/Hour [%]</td></tr><tr>", sessionsByHour.Length);
			for(int i = 0; i < sessionsByHour.Length; i++) {
				sb2.AppendFormat("<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
					(sessionsByHour[i] > 0 ? ((double)((double)sessionsByHour[i] / (double)sessions.Count * 100D)).ToString("N1") : ""),
					(int)((double)sessionsByHour[i] * 100D / (double)max));
			}
			sb2.Append("</tr><tr>");
			for(int i = 0; i < sessionsByHour.Length; i++) {
				sb2.AppendFormat("<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}-{1}</td>", i * 4, i * 4 + 4);
			}
			sb2.Append("</tr></table><br />");

			// Sessions by month
			max = 0;
			for(int i = 0; i < sessionsByMonth.Length; i++) {
				if(sessionsByMonth[i] > max) max = sessionsByMonth[i];
			}
			sb2.AppendFormat("<table style=\"border: solid 1px #CCCCCC;\" cellspacing=\"2\"><tr><td colspan=\"{0}\" style=\"font-size: 10px; text-align: center; border-bottom: solid 1px #CCCCCC;\">Sessions/Month [%]</td></tr><tr>", sessionsByMonth.Length);
			for(int i = sessionsByMonth.Length - 1; i >= 0; i--) {
				sb2.AppendFormat("<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
					(sessionsByMonth[i] > 0 ? ((double)((double)sessionsByMonth[i] / (double)sessions.Count * 100D)).ToString("N1") : ""),
					(int)((double)sessionsByMonth[i] * 100D / (double)max));
			}
			sb2.Append("</tr><tr>");
			for(int i = sessionsByMonth.Length - 1; i >= 0; i--) {
				sb2.AppendFormat("<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>", MonthString(DateTime.Now.AddMonths(-i).Month));
			}
			sb2.Append("</tr></table><br />");

			sb2.Append("</td><td style=\"vertical-align: top;\">");

			sb2.Append("<table cellspacing=\"0\" cellpadding=\"2\">");
			sb2.Append("<tr style=\"background-color: #DDDDDD;\"><td style=\"text-align: right;\"><b>Feature</b></td><td><b>Value</b></td></tr>");

			int idx = 0;
			sb2.Append(GenerateStatsTableRow("Total sessions", sessions.Count.ToString(), false, ref idx));
			sb2.Append(GenerateStatsTableRow("Total hits", totalHits.ToString(), false, ref idx));
			sb2.Append(GenerateStatsTableRow("Avg. sessions/month", sessionsMonth.ToString(), true, ref idx));
			sb2.Append(GenerateStatsTableRow("Avg. sessions/day", sessionsDay.ToString(), true, ref idx));
			sb2.Append(GenerateStatsTableRow("Avg. hits/month", hitsMonth.ToString(), true, ref idx));
			sb2.Append(GenerateStatsTableRow("Avg. hits/day", hitsDay.ToString(), true, ref idx));
			sb2.Append(GenerateStatsTableRow("Avg. hits/page", ((int)(totalHits / pages.Count)).ToString(), true, ref idx));
			sb2.Append(GenerateStatsTableRow("Avg. hits/session", ((int)(totalHits / sessions.Count)).ToString(), true, ref idx));
			//TimeSpan avgDuration = new TimeSpan((long)(totalDuration / sessions.Count * 10000000));
			sb2.Append(GenerateStatsTableRow("Avg. session duration", ((int)(totalDuration / sessions.Count / 60)).ToString() + " min", true, ref idx));
			if(pages.Count >= 2) {
				PageHitCount top1 = GetMostRequestedPage(pages, 1);
				if(top1 != null)
					sb2.Append(GenerateStatsTableRow("1st most requested page", BuildPageLink(top1.Page) + " (" + top1.HitCount.ToString() + " hits)", false, ref idx));
				if(pages.Count > 2) {
					PageHitCount top2 = GetMostRequestedPage(pages, 2);
					if(top2 != null)
						sb2.Append(GenerateStatsTableRow("2nd most requested page", BuildPageLink(top2.Page) + " (" + top2.HitCount.ToString() + " hits)", false, ref idx));
				}
				if(pages.Count > 3) {
					PageHitCount top3 = GetMostRequestedPage(pages, 3);
					if(top3 != null)
						sb2.Append(GenerateStatsTableRow("3rd most requested page", BuildPageLink(top3.Page) + " (" + top3.HitCount.ToString() + " hits)", false, ref idx));
				}
				PageHitCount bottom = GetLeastRequestedPage(pages);
				if(bottom != null)
					sb2.Append(GenerateStatsTableRow("Least requested page", BuildPageLink(bottom.Page) + " (" + bottom.HitCount.ToString() + " hits)", false, ref idx));
			}
			sb2.Append(GenerateStatsTableRow("Statistics life", PrintTimeSpan(life, true, false), false, ref idx));

			sb2.Append("</table><br />");

			// Operating systems/browsers
			max = (int)Math.Max(windows, (int)Math.Max(linux, (int)Math.Max(mac, (int)Math.Max(ie, (int)Math.Max(firefox, safari)))));
			sb2.AppendFormat("<table style=\"border: solid 1px #CCCCCC;\" cellspacing=\"2\"><tr><td colspan=\"6\" style=\"font-size: 10px; text-align: center; border-bottom: solid 1px #CCCCCC;\">Visitors' OS/Browser [%]</td></tr><tr>");
			sb2.AppendFormat("<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
				(windows > 0 ? ((double)((double)windows / (double)sessions.Count * 100D)).ToString("N1") : ""),
				(int)((double)windows * 100D / (double)max));
			sb2.AppendFormat("<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
				(linux > 0 ? ((double)((double)linux / (double)sessions.Count * 100D)).ToString("N1") : ""),
				(int)((double)linux * 100D / (double)max));
			sb2.AppendFormat("<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
				(mac > 0 ? ((double)((double)mac / (double)sessions.Count * 100D)).ToString("N1") : ""),
				(int)((double)mac * 100D / (double)max));
			sb2.AppendFormat("<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
				(ie > 0 ? ((double)((double)ie / (double)sessions.Count * 100D)).ToString("N1") : ""),
				(int)((double)ie * 100D / (double)max));
			sb2.AppendFormat("<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
				(firefox > 0 ? ((double)((double)firefox / (double)sessions.Count * 100D)).ToString("N1") : ""),
				(int)((double)firefox * 100D / (double)max));
			sb2.AppendFormat("<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
				(safari > 0 ? ((double)((double)safari / (double)sessions.Count * 100D)).ToString("N1") : ""),
				(int)((double)safari * 100D / (double)max));
			sb2.Append("</tr><tr>");
			sb2.AppendFormat("<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>", "Win");
			sb2.AppendFormat("<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>", "*nix");
			sb2.AppendFormat("<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>", "Mac");
			sb2.AppendFormat("<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>", "IE");
			sb2.AppendFormat("<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>", "FF");
			sb2.AppendFormat("<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>", "Saf.");
			sb2.Append("</tr></table><br />");

			// Languages
			max = 0;
			for(int i = 0; i < languages.Count; i++) {
				if(languages[i].Value > max) max = languages[i].Value;
			}
			int len = (int)Math.Min(maxLanguages, languages.Count);
			sb2.AppendFormat("<table style=\"border: solid 1px #CCCCCC;\" cellspacing=\"2\"><tr><td colspan=\"{0}\" style=\"font-size: 10px; text-align: center; border-bottom: solid 1px #CCCCCC;\">Visitors' Language [%]</td></tr><tr>", len);
			for(int i = 0; i < len; i++) {
				sb2.AppendFormat("<td style=\"vertical-align: bottom; text-align: center; font-size: 9px;\">{0}<div style=\"width: 34px; height: {1}px; background-color: #214C9A;\">&nbsp;</div></td>",
					(languages[i].Value > 0 ? ((double)((double)languages[i].Value / (double)sessions.Count * 100D)).ToString("N1") : ""),
					(int)((double)languages[i].Value * 100D / (double)max));
			}
			sb2.Append("</tr><tr>");
			for(int i = 0; i < len; i++) {
				sb2.AppendFormat("<td style=\"text-align: center; border-top: solid 1px #CCCCCC; font-size: 10px;\">{0}</td>", languages[i].Key);
			}
			sb2.Append("</tr></table>");

			sb2.Append("</td></tr></table>");

			sb.Replace("%%%SSS%%%", sb2.ToString());

			return sb.ToString();
		}

		private string BuildPageLink(string name) {
			return string.Format("<a class=\"pagelink\" href=\"{0}.ashx\" title=\"{0}\">{0}</a>", name);
		}

		private int GetMonth(DateTime dateTime, int len) {
			int y = DateTime.Now.Year - dateTime.Year;
			int m = DateTime.Now.Month + 12 * y - dateTime.Month;
			if(m > len) return -1;
			else return m;
		}

		private PageHitCount GetMostRequestedPage(List<PageHitCount> pages, int rank) {
			int idx = -1;
			for(int i = 0; i < rank; i++) {
				idx++;
				// Jump all excluded pages
				while(idx < pages.Count - 1 && excludedPages.Contains(pages[idx].Page.ToLower(CultureInfo.CurrentCulture))) idx++;
			}
			if(idx > pages.Count - 1) return null;
			return pages[idx];
		}

		private PageHitCount GetLeastRequestedPage(List<PageHitCount> pages) {
			int idx = pages.Count - 1;
			if(idx < 0) return null;
			while(idx > 0 && excludedPages.Contains(pages[idx].Page.ToLower(CultureInfo.CurrentCulture))) idx--;
			if(idx < 0) return null;
			return pages[idx];
		}

		private string PrintTimeSpan(TimeSpan ts, bool days, bool seconds) {
			StringBuilder sb = new StringBuilder(20);
			if(days) sb.AppendFormat("{0}d ", ts.Days);
			sb.AppendFormat("{0}h {1}m", ts.Hours, ts.Minutes);
			if(seconds) sb.AppendFormat(" {0}s", ts.Seconds);
			return sb.ToString();
		}

		private string GenerateStatsTableRow(string cell1, string cell2, bool tilde, ref int idx) {
			StringBuilder sb = new StringBuilder(100);
			if(idx % 2 == 0) sb.Append("<tr>");
			else sb.Append("<tr style=\"background-color: #F4F4F4;\">");
			sb.Append("<td style=\"text-align: right;\">");
			sb.Append(cell1);
			sb.Append("</td><td><b>");
			if(tilde) sb.Append("~");
			sb.Append(cell2);
			sb.Append("</b></td></tr>");
			idx++;
			return sb.ToString();
		}

		private int DayOfWeekNumber(DayOfWeek dow) {
			switch(dow) {
				case DayOfWeek.Monday:
					return 0;
				case DayOfWeek.Tuesday:
					return 1;
				case DayOfWeek.Wednesday:
					return 2;
				case DayOfWeek.Thursday:
					return 3;
				case DayOfWeek.Friday:
					return 4;
				case DayOfWeek.Saturday:
					return 5;
				case DayOfWeek.Sunday:
					return 6;
				default:
					throw new ArgumentException("invalid day of week", "dow");
			}
		}

		private string DayOfWeekString(int dow) {
			switch(dow) {
				case 0:
					return "mon";
				case 1:
					return "tue";
				case 2:
					return "wed";
				case 3:
					return "thu";
				case 4:
					return "fri";
				case 5:
					return "sat";
				case 6:
					return "sun";
				default:
					throw new ArgumentException("Invalid day of week", "dow");
			}
		}

		private string MonthString(int month) {
			switch(month) {
				case 1:
					return "jan";
				case 2:
					return "feb";
				case 3:
					return "mar";
				case 4:
					return "apr";
				case 5:
					return "may";
				case 6:
					return "jun";
				case 7:
					return "jul";
				case 8:
					return "aug";
				case 9:
					return "sep";
				case 10:
					return "oct";
				case 11:
					return "nov";
				case 12:
					return "dec";
				default:
					throw new ArgumentException("Invalid month", "month");
			}
		}

	}

	/// <summary>
	/// Contains basic information about a Page Hit.
	/// </summary>
	public class PageHit : IComparable<PageHit> {

		protected string page, referer;
		protected DateTime dateTime;

		/// <summary>
		/// Initializes a new instance of the <b>PageHit</b> class.
		/// </summary>
		/// <param name="page">The requested Wiki page.</param>
		/// <param name="dateTime">The date/time.</param>
		public PageHit(string page, string referer, DateTime dateTime) {
			this.page = page;
			this.dateTime = dateTime;
		}

		/// <summary>
		/// Gets or sets the requested Wiki Page.
		/// </summary>
		public string Page {
			get { return page; }
			set { page = value; }
		}

		/// <summary>
		/// Gets or the date/time.
		/// </summary>
		public DateTime DateTime {
			get { return dateTime; }
			set { dateTime = value; }
		}

		public int CompareTo(PageHit other) {
			return dateTime.CompareTo(other.dateTime);
		}

	}

	/// <summary>
	/// Represents a Page Hit.
	/// </summary>
	public class ExtendedPageHit : PageHit {

		private string sessionId, ip, userAgent, language, username;

		/// <summary>
		/// Initializes a new instance of the <b>ExtendedPageHit</b> class.
		/// </summary>
		/// <param name="sessionId">The Session ID.</param>
		/// <param name="ip">The IP Address.</param>
		/// <param name="page">The requested Wiki Page.</param>
		/// <param name="referer">The referer, if any.</param>
		/// <param name="userAgent">The user agent, if any.</param>
		/// <param name="language">The user language, if any.</param>
		/// <param name="username">The username, if any.</param>
		/// <param name="dateTime">The date/time.</param>
		public ExtendedPageHit(string sessionId, string ip, string page, string referer,
			string userAgent, string language, string username, DateTime dateTime)
			: base(page, referer, dateTime) {

			this.sessionId = sessionId;
			this.ip = ip;
			this.userAgent = userAgent;
			this.language = language;
			this.username = username;
		}

		/// <summary>
		/// Gets or sets the Session ID.
		/// </summary>
		public string SessionID {
			get { return sessionId; }
			set { sessionId = value; }
		}

		/// <summary>
		/// Gets or sets the IP Address.
		/// </summary>
		public string IP {
			get { return ip; }
			set { ip = value; }
		}

		/// <summary>
		/// Gets or sets the referer, if any.
		/// </summary>
		public string Referer {
			get { return referer; }
			set { referer = value; }
		}

		/// <summary>
		/// Gets or sets the user agent, if any.
		/// </summary>
		public string UserAgent {
			get { return userAgent; }
			set { userAgent = value; }
		}

		/// <summary>
		/// Gets or sets the user language, if any.
		/// </summary>
		public string Language {
			get { return language; }
			set { language = value; }
		}

		/// <summary>
		/// Gets or sets the Username, if any.
		/// </summary>
		public string Username {
			get { return username; }
			set { userAgent = value; }
		}

		/// <summary>
		/// Gets a string containing the current Page Hit instance for persistent storage.
		/// </summary>
		/// <returns>The string.</returns>
		public string Serialize() {
			// Format
			// <SessionID>|<IP>|<Page>|<Referer>|<UserAgent>|<Username>
			StringBuilder sb = new StringBuilder(50);
			sb.Append(sessionId);
			sb.Append("|");
			sb.Append(ip);
			sb.Append("|");
			sb.Append(SanitizeString(page));
			sb.Append("|");
			if(!string.IsNullOrEmpty(referer)) sb.Append(SanitizeString(referer));
			else sb.Append("-");
			sb.Append("|");
			if(!string.IsNullOrEmpty(userAgent)) sb.Append(SanitizeString(userAgent));
			else sb.Append("-");
			sb.Append("|");
			if(!string.IsNullOrEmpty(language)) sb.Append(SanitizeString(language));
			else sb.Append("-");
			sb.Append("|");
			if(!string.IsNullOrEmpty(username)) sb.Append(SanitizeString(username));
			else sb.Append("-");
			sb.Append("|");
			sb.Append(dateTime.ToString("yyyy'/'MM'/'dd' 'HH':'mm':'ss"));
			return sb.ToString();
		}

		private string SanitizeString(string s) {
			return s.Replace("\r", "").Replace("\r", "").Replace("|", "");
		}

		#region Static Methods

		/// <summary>
		/// Deserializes a string containing a Page Hit instance.
		/// </summary>
		/// <param name="data">The string.</param>
		/// <returns>The instance.</returns>
		public static ExtendedPageHit Deserialize(string data) {
			string[] fields = data.Split('|');
			return new ExtendedPageHit(fields[0], fields[1], fields[2],
				fields[3] != "-" ? fields[3] : "",
				fields[4] != "-" ? fields[4] : "",
				fields[5] != "-" ? fields[5] : "",
				fields[6] != "-" ? fields[6] : "",
				DateTime.Parse(fields[7]));
		}

		#endregion

	}

	/// <summary>
	/// Contains information about a browsing session.
	/// </summary>
	public class SessionInformation : IComparable<SessionInformation> {

		private string sessionId, ip, userAgent, language, username;
		private List<PageHit> hits;

		/// <summary>
		/// Initializes a new instance of the <b>SessionInformation</b> class.
		/// </summary>
		/// <param name="sessionId">The Session ID.</param>
		/// <param name="ip">The IP Address.</param>
		/// <param name="userAgent">The user agent, if any.</param>
		/// <param name="language">The user language, if any.</param>
		/// <param name="username">The username, if any.</param>
		/// <param name="hits">The Page Hits of the Session.</param>
		public SessionInformation(string sessionId, string ip, string userAgent,
			string language, string username, List<PageHit> hits) {
			this.sessionId = sessionId;
			this.ip = ip;
			this.userAgent = userAgent;
			this.language = language;
			this.username = username;
			this.hits = hits;
		}

		/// <summary>
		/// Initializes a new instance of the <b>SessionInformation</b> class.
		/// </summary>
		/// <param name="sessionId">The Session ID.</param>
		/// <param name="ip">The IP Address.</param>
		/// <param name="userAgent">The user agent, if any.</param>
		/// <param name="language">The user language, if any.</param>
		/// <param name="username">The username, if any.</param>
		public SessionInformation(string sessionId, string ip, string userAgent, string language, string username)
			: this(sessionId, ip, userAgent, language, username, new List<PageHit>(20)) { }

		/// <summary>
		/// Gets or sets the Session ID.
		/// </summary>
		public string SessionID {
			get { return sessionId; }
			set { sessionId = value; }
		}

		/// <summary>
		/// Gets or sets the IP Address.
		/// </summary>
		public string IP {
			get { return ip; }
			set { ip = value; }
		}

		/// <summary>
		/// Gets or sets the user agent, if any.
		/// </summary>
		public string UserAgent {
			get { return userAgent; }
			set { userAgent = value; }
		}

		/// <summary>
		/// Gets or sets the user language, if any.
		/// </summary>
		public string Language {
			get { return language; }
			set { language = value; }
		}

		/// <summary>
		/// Gets or sets the Username, if any.
		/// </summary>
		public string Username {
			get { return username; }
			set { userAgent = value; }
		}

		/// <summary>
		/// Gets or sets the page hits.
		/// </summary>
		public List<PageHit> Hits {
			get { return hits; }
			set { hits = value; }
		}

		/// <summary>
		/// Gets the date/time of the first hit in the session.
		/// </summary>
		public DateTime DateTime {
			get {
				return hits[0].DateTime;
			}
		}

		/// <summary>
		/// Gets the estimated duration of the session.
		/// </summary>
		public TimeSpan Duration {
			get {
				if(hits.Count > 1) {
					return hits[hits.Count - 1].DateTime.Subtract(hits[0].DateTime);
				}
				else return new TimeSpan(0L);
			}
		}

		/// <summary>
		/// Gets the estimated average interval between page hits.
		/// </summary>
		public TimeSpan AverageHitInterval {
			get {
				if(hits.Count > 1) {
					double tot = 0;
					for(int i = 0; i < hits.Count - 1; i++) {
						tot += hits[i + 1].DateTime.Subtract(hits[i].DateTime).TotalSeconds;
					}
					return new TimeSpan((long)(tot / (hits.Count - 1)) * 10000000L);
				}
				else return new TimeSpan(0L);
			}
		}

		public int CompareTo(SessionInformation other) {
			return DateTime.CompareTo(other.DateTime);
		}

	}

	/// <summary>
	/// Contains information about the hit count for a Page.
	/// </summary>
	public class PageHitCount {

		private string page;
		private int hitCount;

		/// <summary>
		/// Initializes a new instance of the <b>PageHitCount</b> class.
		/// </summary>
		/// <param name="page">The Page.</param>
		/// <param name="hitCount">The Hit count.</param>
		public PageHitCount(string page, int hitCount) {
			this.page = page;
			this.hitCount = hitCount;
		}

		/// <summary>
		/// Gets or sets the Page.
		/// </summary>
		public string Page {
			get { return page; }
			set { page = value; }
		}

		/// <summary>
		/// Gets or sets the Hit count.
		/// </summary>
		public int HitCount {
			get { return hitCount; }
			set { hitCount = value; }
		}

	}

}
